Skip to content

feat(routes): resolve relative inputSchema imports into one canonical RouteContract shared by MCP and CLI routes (#593) - #603

Merged
ScriptedAlchemy merged 16 commits into
mainfrom
feat/593-route-contract-imports
Sep 5, 2026
Merged

feat(routes): resolve relative inputSchema imports into one canonical RouteContract shared by MCP and CLI routes (#593)#603
ScriptedAlchemy merged 16 commits into
mainfrom
feat/593-route-contract-imports

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Closes #593. Design comment: #593 (comment) (vocabulary from #592: the compiled route graph is the Application IR; the RouteContract is its first graph-level node that routes reference instead of carry).

What changes

Static resolution of inputSchema references — no module is executed. A route may bind its schema to a declaration elsewhere:

// src/cli/status.tsx  and  src/mcp/hauler/tools/hauler_status.tsx
import { statusInputSchema, statusResultSchema } from '../lib/protocol-schemas.js';
export const inputSchema = statusInputSchema;
export const resultSchema = statusResultSchema;

New leaf routes/module-scope.ts is the shared module-scope model and reference resolver: top-level const bindings (exported or not), named value imports, known non-const names (let/var, destructuring, functions, classes, enums, namespaces, default and namespace imports); a reference is followed through same-module aliases and relative imports that resolve inside the project (moduleCandidates order — .js.ts/.tsx, extensionless, index.*), across any number of alias hops, with every visited <module>#<binding> recorded so a cycle is detected deterministically. config-extract.ts (AB4806 string consts, previously one hop) and framework-imports.ts are rewired onto it in the same change; their private scopeOf/resolveImportedConst/scriptKindOf/parseModule copies are deleted.

input-schema.ts reads the schema expression in the scope of the module that declares it. Reference positions the grammar accepts: the inputSchema initializer, a property initializer, the root of a method chain (requestStatusSchema.optional() — the resolved chain's calls come first), z.array(<ref>), z.enum(<ref>) (an as const array of string literals), z.object(<ref>)/z.strictObject(<ref>) (an object literal), .default(<ref>) (a static literal). The zod grammar itself is unchanged; grammar violations inside a resolved schema stay AB4814, with the position qualified by the declaring module (src/lib/protocol-schemas.ts:2:11).

One normalized contract per declared schema. routes/types.ts:

export interface RouteContractOrigin { readonly binding: string; readonly module: string }
export interface RouteContract {
  readonly id: string;                 // `contract:<module>#<binding>`
  readonly input: RouteInputSchema;    // deep-frozen; the SAME object as each bound route's `inputSchema`
  readonly origin: RouteContractOrigin;
  readonly routes: readonly string[];  // sorted route ids
}
// CompiledAgentRoute.contract?: string   CompiledRouteGraph.contracts?: readonly RouteContract[]

Identity is the declaration site: cli:status and tool:hauler/hauler_status importing statusInputSchema bind contract:src/lib/protocol-schemas.ts#statusInputSchema — one node, two routes; a route-local literal is contract:<route>#inputSchema. compileCliCommands projects the argv grammar from the bound contract's input (projectInputSchemaOptions) instead of re-parsing the module, so the argv grammar, the static MCP inputSchema, the route manifest (RouteManifest.contracts, RouteManifestRoute.contract), the Workbench Routes page (origin + shared routes), and inspect --routes all read one object. RouteContract/RouteContractOrigin are exported from agent-bundle/api; RouteManifestContract from agent-bundle/dev and the browser contracts.

Digest. routeIdentity gains contract only when the contract is declared outside the route's own module; inline-only graphs digest exactly as before (pinned in route-graph.test.ts).

resultSchema may be imported the same way today (the contract scan only requires the named export; TypeScript types it through the import; the runtime validates with the real zod object). No static result projection is added — no consumer needs one (#594 generates types from the same typeof chain).

Diagnostics (docs/diagnostics.md; the reference page renders from it)

Code Severity Trigger
AB4838 error A CLI route's inputSchema references a binding the static resolver cannot follow. Message: CLI route <path> inputSchema: inputSchema -> statusInputSchema (src/lib/protocol-schemas.ts) -> requestStatusSchema -> requestStatuses imported from "@shared/protocol", which is not a relative module path. Boundaries: bare specifier, outside the project, missing/unreadable module, missing export const, non-const binding (says what it is), unknown identifier, dynamic initializer (bare call, function, template with substitutions).
AB4839 error The reference chain is cyclic; the message prints the cycle.

Severity: errors only on CLI routes, where the static contract is load-bearing (the command cannot compile without an argv grammar — the same rule as AB4814). MCP tool/resource/prompt, script, and event routes with an unresolvable schema stay silent as today: the runtime derives MCP JSON Schema from the real zod object, and making it an error would break every project that legitimately imports a schema from a package.

Tests

  • Unit (input-schema.test.ts, +21): inline/1-hop/2-hop origins, same-module alias, chain root, z.enum(ref), z.array(ref), z.object(shapeConst), .default(const), cycle, an 11-row unresolved table (bare specifier, outside project, missing module, missing export, let, destructuring, default/namespace/type-only import, dynamic initializer, template), qualified AB4814 inside an imported schema, no source option.
  • cli-routes.test.ts (+3): AB4838, AB4839, imported-vs-inline twin commands deep-equal. route-graph.test.ts (+3): shared CLI+tool contract, inline contract id, imported contract joins the digest; the pre-Application IR: resolve local schema imports so one route contract can drive MCP, CLI argv, Apps, and generated types #593 digest is pinned unchanged. route-config-extract.test.ts (+1): two-hop string const. route-manifest-routes.test.ts and Workbench route-manifest-client/routes-model/routes-page tests cover the manifest/Workbench projection.
  • Integration (route-contract-imports.test.ts, new, registered in rstest.integration-tests.ts): a fixture with src/lib/protocol-schemas.ts importing requestStatuses from src/daemon/protocol.ts, a CLI route and an MCP tool both export const inputSchema = statusInputSchema; export const resultSchema = statusResultSchema;. Asserts one contract with two routes, argv options deep-equal to the inline twin, route.inputSchema object identity across the two routes, build succeeds, the bin's status --help lists --lane-key --limit --statuses --tickets with enum choices, status --statuses queued --statuses done --limit 3 --json returns the canonical result, --statuses bogus exits 2, and generated RouteInput<'cli:status'>RouteInput<'tool:hauler/hauler_status'> (typegen probe compiled with tsc, zero diagnostics, identical to the inline fixture). A bare variant (@shared/protocol) produces exactly one AB4838 naming the chain.

Docs

docs/diagnostics.md, docs/entry-conventions.md; website en + zh: guide/authoring/package-entries.mdx ("Share one schema between MCP and CLI"), guide/authoring/index.mdx ("Route contracts (Application IR)" design section), guide/authoring/mcp.mdx, guide/development/workbench.mdx. Changeset .changeset/593-route-contract-imports.md (agent-bundle: patch).

Consumer proof: cargo-hauler dry-run

Harness under /tmp/593-dryrun (never touches the cargo-hauler checkout): npm pack agent-bundle, @agent-bundle/runtime, rsc-markdown-stream from the worktree; rsync cargo-hauler to a scratch copy; point devDependencies + pnpm-workspace.yaml overrides at the tarballs; apply one patch to src/cli/status.tsx (drop the inline z.object, import { statusInputSchema } from '../lib/protocol-schemas.js'; export const inputSchema = statusInputSchema;); run inspect --routes --json, build, and the built bin.

origin/main (d30d9acb6) this branch (fe96bde28)
inspect --routes exit 1, state: invalid, AB4814 (error): CLI route src/cli/status.tsx has an inputSchema outside the argv grammar: the top level must be z.object({ ... }) or z.strictObject({ ... }). exit 0, state: ready, no diagnostics
contracts absent 14 contracts; contract:src/lib/protocol-schemas.ts#statusInputSchemaroutes: ["cli:status", "tool:hauler/hauler_status"]; #limitInputSchemahauler_last + hauler_log
agent-bundle build exit 1, no dist/bin exit 0; cargo-hauler status --help lists `--command-contains --cwd --lane-key --limit --session --statuses <requested

The patched command's flags become the canonical keys (--lane-key, --tickets, --statuses) instead of cargo-hauler's hand-written --lane/--ticket/--status — precisely the surface-grammar gap #596 closes with an explicit CLI projection; cargo-hauler would adopt the import together with #596 rather than rename its documented flags.

Not in scope

Widening the zod grammar (unions, nested objects, transforms), static result-schema projection, executing modules, non-relative specifiers (package schemas keep working at runtime; they are not statically visible), CLI flag renames (#596).

Self-review

Implementation lanes: core resolver/graph on Claude (claude-fable-5-1-thinking-max), manifest/Workbench + integration fixture on GPT-5.6 Sol, unit tests + dry-run harness on Grok 4.6, docs en on Claude, docs zh on GPT-5.6 Sol. Reviewer: generalPurpose subagent on GPT-5.6 Sol (different model from the core author; change-risk-reviewer cannot run while TraceDecay's MCP endpoint is down on this machine), briefed to return concrete merge risks only across: resolver edge cases (re-exports, import { X as Y }, type-only imports, declare const, cycle keying, miss caching), regressions vs main (the new dynamic-initializer rule, AB4806 multi-hop, the framework-imports.ts rewire), digest stability, declaration hygiene (pnpm lint:release), dead code, manifest/Workbench decoder strictness, test coverage of the claimed cases, and docs/changeset accuracy.

Findings: none — the reviewer ran pnpm lint:release (pass), the 275 focused unit tests and the route-contract integration test (pass), and confirmed the changeset suffix. Integrator-side fixes made while integrating the lanes, before the review: (1) a reference that resolves to a dynamic initializer (bare call, function, template with substitutions) is reported as AB4838 with the chain, per the design, rather than as an AB4814 grammar issue — isStaticInitializer in input-schema.ts; (2) the unresolved chain names the failing binding as written in the importing module and puts the specifier in the reason (inputSchema -> statusInputSchema imported from "./missing", which …), matching the design example; (3) an inline { ...spread } stays AB4814 (it is a grammar issue, not a reference); (4) the integration fixture types its route parameter so the typegen probe compiles under strict; (5) RouteContract/RouteContractOrigin exported from agent-bundle/api, RouteManifestContract from agent-bundle/dev and the browser contracts; docs updated to match all of the above. Merged origin/main (#599 docs audit) with one table-row conflict in docs/diagnostics.md, resolved by keeping both rows; full gate (build, typecheck, lint, unit, route-unit, projection, docs site) green after the merge.

…or API

Cover parse/extract resolution, CLI AB4838/AB4839, shared RouteContract identity, and two-hop config aliases so the suite fails on current main for the right reasons and goes green after the L1 merge.
…IR, AB4838/AB4839 (#593 lane L5)

- docs/diagnostics.md: rewrite the argv-grammar paragraph around static
  reference resolution, add the RouteContract paragraph, widen the AB4806
  string-const form to alias hops across relative modules, qualify AB4814
  by the declaring module, add AB4838/AB4839 rows, bump the section range.
- docs/entry-conventions.md: schema may be declared in a relative module
  the route imports; fix the Diagnostics section anchor.
- website (en): 'Share one schema between MCP and CLI' (package-entries),
  'Route contracts (Application IR)' (authoring index), one paragraph in
  mcp.mdx, a Routes row in the Workbench page table.
- changeset 593-route-contract-imports.md (agent-bundle: patch).
…te contracts (#593, lane L1)

Add routes/module-scope.ts, the static module-scope model and reference
resolver: top-level consts, named relative imports (moduleCandidates
order, rejected outside projectRoot), alias hops any depth, cycle
detection, printable chains, structural node slices (no ts.* in exported
signatures). config-extract.ts and framework-imports.ts are rewired onto
it in the same change (private scopeOf/ModuleScope/ImportedBinding/
resolveImportedConst/insideProject/parseModule/scriptKindOf copies
deleted; AB4806 fragments unchanged, const string references now
multi-hop).

input-schema.ts follows an inputSchema reference to its declaring
module and parses the zod chain in that scope: initializer, property
initializer, chain root (resolved calls first), z.array(ref),
z.enum(ref as-const array), z.object(ref), .default(ref). Grammar issues
keep their AB4814 wording with positions in other modules qualified as
<module>:<line>:<col>. parseInputSchema/extractInputSchema take
InputSchemaExtractionOptions and report origin/resolution;
extractInputSchema returns { origin, schema }.

cli-argv.ts maps resolution failures to AB4838 (unresolvable) and
AB4839 (cycle), adds ExtractedCliArgv.origin, and exports
projectInputSchemaOptions so compileCliCommands projects the argv
surface from the canonical RouteInputSchema when the graph bound a
contract (re-parsing the module only to report why none exists).

graph.ts binds routes declaring one schema to one RouteContract
(contract:<module>#<binding>) sharing a single frozen input object,
sets CompiledAgentRoute.contract and CompiledRouteGraph.contracts (from
the final route set, sorted, omitted when empty); an imported contract
id joins routeIdentity, a route-local literal does not, so inline-only
graphs digest unchanged. types.ts/index.ts add RouteContract and
RouteContractOrigin.
…rom agent-bundle/api and RouteManifestContract from dev; align fixtures and docs with the resolver
@changeset-bot

changeset-bot Bot commented Sep 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 10d3d92

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T06:39:04.561075Z fe96bde PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@603
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@603
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@603
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@603

commit: 10d3d92

@ScriptedAlchemy
ScriptedAlchemy enabled auto-merge (squash) September 5, 2026 06:45
@ScriptedAlchemy
ScriptedAlchemy merged commit c9546b4 into main Sep 5, 2026
17 checks passed
ScriptedAlchemy added a commit that referenced this pull request Sep 5, 2026
…at/592-authoritative-manifest

Resolve docs/diagnostics.md, api.ts, pack-inventory.ts, route-manifest.ts.
Project routes.contracts[] and route.contract into the artifact manifest via
the shared manifest-routes projection; add them to the shipped JSON Schema
and the en/zh reference page (no longer a reserved key).
@ScriptedAlchemy
ScriptedAlchemy deleted the feat/593-route-contract-imports branch September 5, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Application IR: resolve local schema imports so one route contract can drive MCP, CLI argv, Apps, and generated types

1 participant